Skip to content

[lexical-extension][lexical-react] Refactor: publish @lexical/extension as subpath exports - #9155

Closed
potatowagon wants to merge 1 commit into
facebook:mainfrom
potatowagon:extension-subpath-exports
Closed

potatowagon wants to merge 1 commit into
facebook:mainfrom
potatowagon:extension-subpath-exports

Conversation

@potatowagon

Copy link
Copy Markdown
Contributor

Description

@lexical/extension publishes a single entry point that contains both the
builder core and all 20 bundled extension implementations. Anything that
builds an editor — LexicalExtensionComposer, or buildEditorFromExtensions
directly — pulls the whole module even when it uses nothing but
defineExtension and LexicalBuilder.

That is invisible to a bundler that can tree-shake, which is most of them.
It is not invisible to a consumer that compiles the published bundles
without shaking them: Meta's www vendors this repo's Rollup output as one
pre-bundled CommonJS module per package, so requiring the entry point costs
39.9 kB raw / 12.9 kB gzipped there regardless of what is used. The same
applies to any consumer loading dist/ through a CJS path.

This changes the package to the shape @lexical/react already uses: one
export per src/*.ts, with index.ts kept as a barrel so
import {...} from '@lexical/extension' continues to resolve everything.

  • main/module are dropped from package.json, which is what makes
    updateVersion.mjs generate one export entry per source file rather than
    a single . entry. The exports map, flow stubs, tsconfigs and www stubs
    in this diff are all generated by pnpm run update-packages.
  • Sibling imports inside the package move from relative (./signals) to
    package-qualified (@lexical/extension/signals), matching
    @lexical/react. This is what keeps shared code shared: a relative import
    is inlined into each entry that uses it, so with 18 modules importing
    ./signals, splitting without this would put 18 copies of the signals
    re-export — and, in the www build where @preact/signals-core is not
    external, 18 copies of the signals runtime — into the output. Signals
    state is module-scope, so duplicates do not merely cost bytes, they break
    batching and effect tracking. Verified below that exactly one module
    contains it.
  • @lexical/react's own imports move to the subpaths, so the split
    actually reaches the React entry points rather than being available in
    principle.

No public API changes: every symbol previously exported from
@lexical/extension is still exported from it.

Test plan

Before

Everything reachable from LexicalExtensionComposer, www build at 0.50.0:

LexicalExtension        39906 raw  12860 gz   (20 extensions + signals runtime)

After

pnpm run build-www, then walking require() edges from
LexicalExtensionComposer:

LexicalExtensionConfig                              733 raw    458 gz
LexicalExtensionDeepThemeMergeInPlace               700 raw    457 gz
LexicalExtensionExtensionRep                       5233 raw   1683 gz
LexicalExtensionGetExtensionDependencyFromEditor    966 raw    580 gz
LexicalExtensionInitialStateExtension              1176 raw    666 gz
LexicalExtensionLexicalBuilder                     5783 raw   2224 gz
TOTAL                                             14591 raw   6068 gz

6 of 33 modules, down from the whole package: -25,315 raw / -6,792 gz,
53% of the gzipped cost.
The signals runtime is no longer reachable from
an editor build at all, and the barrel is not reachable either.

Signals is in exactly one module (www build, where it is inlined rather
than external):

$ grep -l "preact-signals" packages/lexical-extension/dist/*.prod.js
packages/lexical-extension/dist/LexicalExtensionSignals.prod.js

Backwards compatibility — all 58 value exports still resolve from the
barrel after the split:

barrel runtime exports after split: 58
value exports expected: 58
MISSING from barrel: none

Suites:

$ pnpm run test-unit
 Test Files  324 passed (324)
      Tests  7790 passed | 1 skipped (7791)

$ npx vitest run scripts/__tests__/integration/tree-shaking.test.mjs
 Test Files  1 passed (1)
      Tests  270 passed (270)

$ pnpm run ci-check
(tsc, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint)
exit 0

E2E and browser suites were not run in this environment.

Context: #9153.

…on as subpath exports

## Description

`@lexical/extension` publishes a single entry point that contains both the
builder core and all 20 bundled extension implementations. Anything that
builds an editor — `LexicalExtensionComposer`, or `buildEditorFromExtensions`
directly — pulls the whole module even when it uses nothing but
`defineExtension` and `LexicalBuilder`.

That is invisible to a bundler that can tree-shake, which is most of them.
It is not invisible to a consumer that compiles the published bundles
without shaking them: Meta's www vendors this repo's Rollup output as one
pre-bundled CommonJS module per package, so requiring the entry point costs
39.9 kB raw / 12.9 kB gzipped there regardless of what is used. The same
applies to any consumer loading `dist/` through a CJS path.

This changes the package to the shape `@lexical/react` already uses: one
export per `src/*.ts`, with `index.ts` kept as a barrel so
`import {...} from '@lexical/extension'` continues to resolve everything.

- `main`/`module` are dropped from `package.json`, which is what makes
  `updateVersion.mjs` generate one export entry per source file rather than
  a single `.` entry. The exports map, flow stubs, tsconfigs and www stubs
  in this diff are all generated by `pnpm run update-packages`.
- Sibling imports inside the package move from relative (`./signals`) to
  package-qualified (`@lexical/extension/signals`), matching
  `@lexical/react`. This is what keeps shared code shared: a relative import
  is inlined into each entry that uses it, so with 18 modules importing
  `./signals`, splitting without this would put 18 copies of the signals
  re-export — and, in the www build where `@preact/signals-core` is not
  external, 18 copies of the signals runtime — into the output. Signals
  state is module-scope, so duplicates do not merely cost bytes, they break
  batching and effect tracking. Verified below that exactly one module
  contains it.
- `@lexical/react`'s own imports move to the subpaths, so the split
  actually reaches the React entry points rather than being available in
  principle.

No public API changes: every symbol previously exported from
`@lexical/extension` is still exported from it.

## Test plan

### Before

Everything reachable from `LexicalExtensionComposer`, www build at 0.50.0:

```
LexicalExtension        39906 raw  12860 gz   (20 extensions + signals runtime)
```

### After

`pnpm run build-www`, then walking `require()` edges from
`LexicalExtensionComposer`:

```
LexicalExtensionConfig                              733 raw    458 gz
LexicalExtensionDeepThemeMergeInPlace               700 raw    457 gz
LexicalExtensionExtensionRep                       5233 raw   1683 gz
LexicalExtensionGetExtensionDependencyFromEditor    966 raw    580 gz
LexicalExtensionInitialStateExtension              1176 raw    666 gz
LexicalExtensionLexicalBuilder                     5783 raw   2224 gz
TOTAL                                             14591 raw   6068 gz
```

6 of 33 modules, down from the whole package: **-25,315 raw / -6,792 gz,
53% of the gzipped cost.** The signals runtime is no longer reachable from
an editor build at all, and the barrel is not reachable either.

Signals is in exactly one module (www build, where it is inlined rather
than external):

```
$ grep -l "preact-signals" packages/lexical-extension/dist/*.prod.js
packages/lexical-extension/dist/LexicalExtensionSignals.prod.js
```

Backwards compatibility — all 58 value exports still resolve from the
barrel after the split:

```
barrel runtime exports after split: 58
value exports expected: 58
MISSING from barrel: none
```

Suites:

```
$ pnpm run test-unit
 Test Files  324 passed (324)
      Tests  7790 passed | 1 skipped (7791)

$ npx vitest run scripts/__tests__/integration/tree-shaking.test.mjs
 Test Files  1 passed (1)
      Tests  270 passed (270)

$ pnpm run ci-check
(tsc, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint)
exit 0
```

E2E and browser suites were not run in this environment.
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Sep 11, 2026 9:30am UTC
lexical-playground Ready Ready Preview Sep 11, 2026 9:30am UTC

Request Review

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is really sufficient for your needs because most other packages like rich-text have dependencies on @lexical/extension as well.

The right move here is likely to have these subpath imports managed by eslint or a build plugin to eliminate all monorepo barrel imports


/**
* LexicalExtensionAutoFocusExtension
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like all of the flow types are missing from these stubs, they need to be moved from the barrel flow file. For example this line belongs in this file (plus any imports to make it work):

declare export var AutoFocusExtension: LexicalExtension<AutoFocusConfig, "@lexical/extension/AutoFocus", NamedSignalsOutput<AutoFocusConfig>, void>;

This branch was successfully deployed

2 active deployments
Preview – lexical 7cb9bb04 Deployed Sep 11, 2026 by vercel[bot]
Preview – lexical-playground 7cb9bb04 Deployed Sep 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants